/*
 * This file is part of Aliucord, an Android Discord client mod.
 * Copyright (c) 2024 Juby210 & Vendicated
 * Licensed under the Open Software License version 3.0
 */

package com.aliucord;

import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Typeface;
import android.net.Uri;
import android.os.*;
import android.provider.Settings;
import android.text.*;
import android.text.style.AbsoluteSizeSpan;
import android.view.*;
import android.widget.*;

import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.DrawableRes;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.AppCompatImageButton;
import androidx.core.content.ContextCompat;
import androidx.core.content.res.ResourcesCompat;
import androidx.fragment.app.Fragment;

import com.aliucord.entities.CorePlugin;
import com.aliucord.entities.Plugin;
import com.aliucord.fragments.ConfirmDialog;
import com.aliucord.patcher.*;
import com.aliucord.settings.*;
import com.aliucord.updater.PluginUpdater;
import com.aliucord.utils.ChangelogUtils;
import com.aliucord.utils.ReflectUtils;
import com.aliucord.views.Divider;
import com.aliucord.views.ToolbarButton;
import com.aliucord.widgets.SideloadingBlockWarning;
import com.aliucord.wrappers.embeds.MessageEmbedWrapper;
import com.discord.api.message.embed.EmbedField;
import com.discord.app.*;
import com.discord.databinding.*;
import com.discord.models.domain.*;
import com.discord.models.domain.emoji.ModelEmojiCustom;
import com.discord.models.domain.emoji.ModelEmojiUnicode;
import com.discord.stores.*;
import com.discord.utilities.color.ColorCompat;
import com.discord.utilities.guildautomod.AutoModUtils;
import com.discord.utilities.user.UserUtils;
import com.discord.widgets.changelog.WidgetChangeLog;
import com.discord.widgets.chat.input.SmoothKeyboardReactionHelper;
import com.discord.widgets.chat.list.WidgetChatList;
import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemAutoModSystemMessageEmbed;
import com.discord.widgets.chat.list.entries.AutoModSystemMessageEmbedEntry;
import com.discord.widgets.chat.list.entries.ChatListEntry;
import com.discord.widgets.debugging.WidgetDebugging;
import com.discord.widgets.guilds.profile.WidgetChangeGuildIdentity;
import com.discord.widgets.guilds.profile.WidgetGuildProfileSheet$configureGuildActions$$inlined$apply$lambda$4;
import com.discord.widgets.home.WidgetHome;
import com.discord.widgets.settings.WidgetSettings;
import com.discord.widgets.settings.profile.WidgetEditUserOrGuildMemberProfile;
import com.discord.widgets.status.*;
import com.lytefast.flexinput.R;

import java.io.*;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.sql.Timestamp;
import java.util.*;

import dalvik.system.PathClassLoader;
import kotlin.io.FilesKt;

public final class Main {
    /** Whether Aliucord has been preInitialized */
    public static boolean preInitialized = false;
    /** Whether Aliucord has been initialized */
    public static boolean initialized = false;
    public static final Logger logger = new Logger();

    private static boolean loadedPlugins;

    public static SettingsUtilsJSON settings;

    /**
     * Aliucord's preInit hook. Plugins are loaded here
     * @noinspection unused
     */
    public static void preInit(AppActivity activity) throws NoSuchMethodException {
        if (preInitialized) return;
        preInitialized = true;

        Utils.appActivity = activity;

        if (checkPermissions(activity)) preInitWithPermissions(activity);

        Patcher.addPatch(AppActivity.class, "onCreate", new Class<?>[]{ Bundle.class }, new Hook(param ->
            Utils.appActivity = (AppActivity) param.thisObject));

        Patcher.addPatch(WidgetChatList.class.getDeclaredConstructor(), new Hook(param ->
            Utils.widgetChatList = (WidgetChatList) param.thisObject));

        // Fix 2025-04-03 gateway change that ported visual refresh theme names over the legacy user settings
        // Theme entries like "darker" and "midnight" are unsupported
        Patcher.addPatch(ModelUserSettings.class, "assignField", new Class<?>[]{ Model.JsonReader.class }, new Hook(param -> {
            var $this = (ModelUserSettings) param.thisObject;

            var theme = $this.getTheme();
            if (theme == null ||
                ModelUserSettings.THEME_DARK.equals(theme) ||
                ModelUserSettings.THEME_LIGHT.equals(theme) ||
                ModelUserSettings.THEME_PURE_EVIL.equals(theme)) return;

            try {
                ReflectUtils.setField($this, "theme", "dark");
            } catch (Exception e) {
                logger.error("Failed to fix ModelUserSettings theme", e);
            }
        }));
    }

    private static void preInitWithPermissions(AppCompatActivity activity) {
        settings = new SettingsUtilsJSON("Aliucord");
        PluginManager.loadCorePlugins(activity);
        loadAllPlugins(activity);
    }

    /**
     * Aliucord's init hook. Plugins are started here
     * @noinspection unused
     */
    public static void init(AppActivity activity) {
        if (initialized) return;
        initialized = true;

        Patcher.addPatch(WidgetSettings.class, "onViewBound", new Class<?>[]{ View.class }, new Hook(param -> {
            ViewGroup layout = Utils.nestedChildAt((ViewGroup) param.args[0], 1, 0);
            Context context = layout.getContext();

            int baseIndex = layout.indexOfChild(layout.findViewById(Utils.getResId("developer_options_divider", "id")));

            layout.addView(new Divider(context), baseIndex++);

            var header = new TextView(context, null, 0, R.i.UiKit_Settings_Item_Header);
            header.setText("Aliucord");
            header.setTypeface(ResourcesCompat.getFont(context, Constants.Fonts.whitney_semibold));
            layout.addView(header, baseIndex++);

            var font = ResourcesCompat.getFont(context, Constants.Fonts.whitney_medium);

            layout.addView(
                makeSettingsEntry(font, context, "Settings", R.e.ic_behavior_24dp, AliucordPage.class),
                baseIndex++
            );
            layout.addView(
                makeSettingsEntry(font, context, "Plugins", R.e.ic_clear_all_white_24dp, Plugins.class),
                baseIndex++
            );
            layout.addView(
                makeSettingsEntry(font, context, "Updater", R.e.ic_file_download_white_24dp, Updater.class),
                baseIndex++
            );
            layout.addView(
                makeSettingsEntry(font, context, "Crashes", R.e.ic_history_white_24dp, Crashes.class),
                baseIndex++
            );
            layout.addView(
                makeSettingsEntry(font, context, "Open Debug Log", R.e.ic_audit_logs_24dp, WidgetDebugging.class),
                baseIndex
            );

            TextView versionView = layout.findViewById(Utils.getResId("app_info_header", "id"));
            var text = versionView.getText() + " | Aliucord " + BuildConfig.VERSION;
            if (!BuildConfig.RELEASE) text += " (Custom)";
            if (Utils.isDebuggable()) text += " [DEBUGGABLE]";
            versionView.setText(text);

            TextView uploadLogs = layout.findViewById(Utils.getResId("upload_debug_logs", "id"));
            uploadLogs.setText("Aliucord Support Server");
            uploadLogs.setOnClickListener(e -> Utils.joinSupportServer(e.getContext()));

            // Remove Discord changelog button
            TextView changelogBtn = layout.findViewById(Utils.getResId("changelog", "id"));
            changelogBtn.setVisibility(View.GONE);
        }));

        // Add permanent indicator if safe mode is enabled.
        Patcher.addPatch(WidgetGlobalStatusIndicator.class, "configureUI", new Class<?>[] {WidgetGlobalStatusIndicatorViewModel.ViewState.class }, new PreHook(param -> {
            if (!PluginManager.isSafeModeEnabled()) {
                return;
            }
            WidgetGlobalStatusIndicator indicator = (WidgetGlobalStatusIndicator) param.thisObject;
            Context context = indicator.requireContext();

            try {
                var binding = (WidgetGlobalStatusIndicatorBinding) ReflectUtils.invokeMethod(indicator, "getBinding");
                var indicatorState = (WidgetGlobalStatusIndicatorState) ReflectUtils.getField(indicator, "indicatorState");

                int backgroundColor = Utils.getResId("colorBackgroundTertiary", "attr");
                int textColor = Utils.getResId("colorHeaderPrimary", "attr");

                LinearLayout linearLayout = binding.c;
                TextView indicatorText = binding.i;
                linearLayout.setBackgroundColor(ColorCompat.getThemedColor(context, backgroundColor));
                linearLayout.setOnClickListener(widget -> safeModeDialog(indicator));
                indicatorText.setTextColor(ColorCompat.getThemedColor(context, textColor));
                indicatorText.setText("Safe mode enabled");
                linearLayout.setVisibility(View.VISIBLE);

                Utils.mainThread.post(() -> indicatorState.updateState(true, false, false));
            } catch (Throwable e) {
                logger.error(e);
                return;
            }

            param.setResult(null);
        }));

        // Patch to repair built-in emotes is needed because installer doesn't recompile resources,
        // so they stay in package com.discord instead of apk package name
        Patcher.addPatch(ModelEmojiUnicode.class, "getImageUri", new Class<?>[]{ String.class, Context.class },
            new InsteadHook(param -> "res:///" + Utils.getResId("emoji_" + param.args[0], "raw"))
        );

        // Patch to fix crash when displaying newer AutoMod embed types like "Quarantined a member at username update"
        Patcher.addPatch(WidgetChatListAdapterItemAutoModSystemMessageEmbed.class, "onConfigure", new Class<?>[]{ int.class, ChatListEntry.class },
            new PreHook(param -> {
                try {
                    var autoModEntry = (AutoModSystemMessageEmbedEntry) param.args[1];

                    // If the channel_id embed field is missing, then just add one set to 0, it'll be displayed as null
                    if ("".equals(AutoModUtils.INSTANCE.getEmbedFieldValue(autoModEntry.getEmbed(), "channel_id"))) {
                        var fields = (ArrayList<EmbedField>) new MessageEmbedWrapper(autoModEntry.getEmbed()).getRawFields();

                        var newField = ReflectUtils.allocateInstance(EmbedField.class);
                        ReflectUtils.setField(newField, "name", "channel_id");
                        ReflectUtils.setField(newField, "value", "0");

                        fields.add(newField);
                    }
                } catch (Throwable e) { logger.error(e); }
            })
        );

        // Patch to allow changelogs without media
        Patcher.addPatch(WidgetChangeLog.class, "configureMedia", new Class<?>[]{ String.class }, new PreHook(param -> {
            WidgetChangeLog _this = (WidgetChangeLog) param.thisObject;
            String media = _this.getMostRecentIntent().getStringExtra("INTENT_EXTRA_VIDEO");

            if (media == null) {
                WidgetChangeLogBinding binding = WidgetChangeLog.access$getBinding$p(_this);
                binding.i.setVisibility(View.GONE); // changeLogVideoOverlay
                binding.h.setVisibility(View.GONE); // changeLogVideo
                param.setResult(null);
            }
        }));

        // Patch for custom footer actions
        Patcher.addPatch(WidgetChangeLog.class, "configureFooter", new Class<?>[0], new PreHook(param -> {
            WidgetChangeLog _this = (WidgetChangeLog) param.thisObject;
            WidgetChangeLogBinding binding = WidgetChangeLog.access$getBinding$p(_this);

            @SuppressWarnings({"deprecation", "RedundantSuppression"})
            Parcelable[] actions = _this.getMostRecentIntent().getParcelableArrayExtra("INTENT_EXTRA_FOOTER_ACTIONS");

            if (actions == null) {
                return;
            }

            AppCompatImageButton twitterButton = binding.g;
            LinearLayout parent = (LinearLayout) twitterButton.getParent();

            parent.removeAllViewsInLayout();

            for (Parcelable parcelable : actions) {
                ChangelogUtils.FooterAction action = ((ChangelogUtils.FooterAction) parcelable);

                ToolbarButton button = new ToolbarButton(parent.getContext());
                button.setImageDrawable(ContextCompat.getDrawable(parent.getContext(), action.getDrawableResourceId()), false);

                button.setPadding(twitterButton.getPaddingLeft(), twitterButton.getPaddingTop(), twitterButton.getPaddingRight(), twitterButton.getPaddingBottom());
                button.setLayoutParams(twitterButton.getLayoutParams());

                button.setOnClickListener(v -> Utils.launchUrl(action.getUrl()));

                parent.addView(button);
            }

            param.setResult(null);
        }));

        // add stacktraces in debug logs page
        try {
            Class<WidgetDebugging.Adapter.Item> c = WidgetDebugging.Adapter.Item.class;
            Field debugItemBinding = c.getDeclaredField("binding");
            debugItemBinding.setAccessible(true);

            Patcher.addPatch(c, "onConfigure", new Class<?>[]{ int.class, AppLog.LoggedItem.class }, new Hook(param -> {
                AppLog.LoggedItem loggedItem = (AppLog.LoggedItem) param.args[1];
                Throwable th = loggedItem.m;
                if (th != null) try {
                    TextView logMessage = ((WidgetDebuggingAdapterItemBinding) debugItemBinding.get(param.thisObject)).b;
                    SpannableStringBuilder sb = new SpannableStringBuilder("\n  at ");
                    StackTraceElement[] s = th.getStackTrace();
                    sb.append(TextUtils.join("\n  at ", s.length > 12 ? Arrays.copyOfRange(s, 0, 12) : s));
                    sb.setSpan(new AbsoluteSizeSpan(12, true), 0, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                    logMessage.append(sb);
                } catch (Throwable e) { logger.error(e); }
            }));
        } catch (Throwable e) { logger.error(e); }

        Thread.setDefaultUncaughtExceptionHandler(Main::crashHandler);

        // use new member profile editor for nitro users
        try {
            Patcher.addPatch(
                WidgetGuildProfileSheet$configureGuildActions$$inlined$apply$lambda$4.class.getDeclaredMethod("invoke", View.class),
                new InsteadHook(param -> {
                    var ctx = ((View) param.args[0]).getContext();
                    var gId = ((WidgetGuildProfileSheet$configureGuildActions$$inlined$apply$lambda$4) param.thisObject).$guildId$inlined;
                    if (UserUtils.INSTANCE.isPremiumTier2(StoreStream.getUsers().getMe()))
                        WidgetEditUserOrGuildMemberProfile.Companion.launch(ctx, null, gId);
                    else
                        WidgetChangeGuildIdentity.Companion.launch(gId, "Guild Bottom Sheet", ctx);
                    return null;
                })
            );
        } catch (Throwable e) { logger.error(e); }

        // not sure why this happens, reported on Android 15 Beta 4
        // java.lang.IllegalArgumentException: Animators cannot have negative duration: -1
        //   at android.view.ViewPropertyAnimator.setDuration(ViewPropertyAnimator.java:266)
        //   at com.discord.widgets.chat.input.SmoothKeyboardReactionHelper$Callback.onStart(SmoothKeyboardReactionHelper.kt:5)
        //   at android.view.View.dispatchWindowInsetsAnimationStart(View.java:12671)
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
            try {
                Patcher.addPatch(
                    SmoothKeyboardReactionHelper.Callback.class.getDeclaredMethod("onStart", WindowInsetsAnimation.class, WindowInsetsAnimation.Bounds.class),
                    new PreHook(param -> {
                        var animation = (WindowInsetsAnimation) param.args[0];
                        if (animation.getDurationMillis() < 0) param.setResult(param.args[1]);
                    })
                );
            } catch (Throwable e) {
                logger.error("Couldn't patch possible Android 15 (?) crash", e);
            }
        }

        // Disable the Discord changelog page
        Patcher.addPatch(StoreChangeLog.class,
            "shouldShowChangelog",
            new Class[]{ Context.class, long.class, String.class, Integer.class },
            new InsteadHook(param -> false)
        );

        // Disable the google play rating request dialog
        Patcher.addPatch(StoreReviewRequest.class,
            "handleConnectionOpen",
            new Class[]{ ModelPayload.class },
            new InsteadHook(param -> null)
        );

        // Disable school hubs dialog upon login
        Patcher.addPatch(StoreNotices.class,
            "hasBeenSeen",
            new Class[]{ String.class },
            new PreHook(param -> {
                if ("WidgetHubEmailFlow".equals((String) param.args[0]))
                    param.setResult(true);
            })
        );
        Patcher.addPatch(WidgetHome.class,
            "maybeShowHubEmailUpsell",
            new InsteadHook(param -> null)
        );

        // Support webp emojis by forcing every emoji to be webp
        Patcher.addPatch(ModelEmojiCustom.class,
            "getImageUri",
            new Class[]{ long.class, boolean.class, int.class },
            new InsteadHook(param ->
                String.format(
                    Locale.ROOT,
                    "https://cdn.discordapp.com/emojis/%s.webp?size=%s&animated=%s",
                    param.args[0],
                    param.args[2],
                    param.args[1]
                )
            )
        );

        if (loadedPlugins) {
            PluginManager.startCorePlugins();
            startAllPlugins();
        }

        SideloadingBlockWarning.INSTANCE.maybeOpenDialog();
    }

    private static void crashHandler(Thread thread, Throwable throwable) {
        if (Looper.getMainLooper().getThread() != thread) {
            logger.error("Uncaught exception on thread " + thread.getName(), throwable);
            return;
        }
        new Thread() {
            @Override
            public void run() {
                Looper.prepare();
                String badPlugin = null;
                boolean disabledPlugin = false;
                for (StackTraceElement ele : throwable.getStackTrace()) {
                    String className = ele.getClassName();

                    for (Map.Entry<PathClassLoader, Plugin> entry : PluginManager.classLoaders.entrySet()) {
                        try {
                            var loadedClass = entry.getKey().loadClass(className);
                            if (!loadedClass.getClassLoader().equals(entry.getKey())) {
                                // class was loaded from the parent classloader, ignore
                                continue;
                            }

                            badPlugin = entry.getValue().getName();
                            if (Main.settings.getBool(AliucordPageKt.AUTO_DISABLE_ON_CRASH_KEY, true)) {
                                disabledPlugin = true;
                                Main.settings.setBool(PluginManager.getPluginPrefKey(badPlugin), false);
                            }
                            break;
                        } catch (ClassNotFoundException ignored) {
                        }
                    }

                    if (badPlugin != null) {
                        break;
                    }
                }
                File folder = new File(Constants.CRASHLOGS_PATH);
                if (folder.exists() || folder.mkdir()) {
                    File file = new File(folder, new Timestamp(System.currentTimeMillis()).toString().replaceAll(":", "_") + ".txt");
                    try (PrintStream ps = new PrintStream(file)) {
                        throwable.printStackTrace(ps);
                    } catch (FileNotFoundException ignored) {}
                }

                var sb = new StringBuilder("An unrecoverable crash occurred. ");
                if (badPlugin != null) {
                    sb.append("This crash was caused by ").append(badPlugin);
                    if (disabledPlugin) {
                        sb.append(", so I automatically disabled it for you");
                    }
                    sb.append(". ");
                }
                sb.append("Check the crashes section in the settings for more info.");

                Toast.makeText(Utils.getAppContext(), sb.toString(), Toast.LENGTH_LONG).show();
                Looper.loop();
            }
        }.start();
        try {
            Thread.sleep(4200); // Wait for toast to end
        } catch (InterruptedException ignored) {}
        System.exit(2);
    }

    private static TextView makeSettingsEntry(Typeface font, Context context, String text, @DrawableRes int resId, Class<? extends AppComponent> component) {
        var view = new TextView(context, null, 0, R.i.UiKit_Settings_Item_Icon);
        view.setText(text);
        view.setTypeface(font);
        var icon = ContextCompat.getDrawable(context, resId);
        if (icon != null) {
            icon.mutate().setTint(ColorCompat.getThemedColor(context, R.b.colorInteractiveNormal));
            view.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null);
        }
        view.setOnClickListener(e -> Utils.openPage(e.getContext(), component));
        return view;
    }

    private static void loadAllPlugins(Context context) {
        if (PluginManager.isSafeModeEnabled()) {
            logger.warn("Safe mode is enabled. skipping loading external plugins");
            loadedPlugins = true;
            return;
        }
        File dir = new File(Constants.PLUGINS_PATH);
        if (!dir.exists()) {
            boolean res = dir.mkdirs();
            if (!res) {
                logger.error("Failed to create directories!", null);
                return;
            }
        }

        File[] sortedPlugins = dir.listFiles();
        if (sortedPlugins != null) {
            // Always sort plugins alphabetically for reproducible results
            Arrays.sort(sortedPlugins, Comparator.comparing(File::getName));

            for (File f : sortedPlugins) {
                var name = f.getName();
                if (name.endsWith(".zip")) {
                    PluginManager.loadPlugin(context, f);
                } else if (!name.equals("oat")) { // Some roms create this
                    if (f.isDirectory()) {
                        Utils.showToast(
                            String.format("Found directory %s in your plugins folder. DO NOT EXTRACT PLUGIN ZIPS!", name),
                            true
                        );
                    } else if (name.equals("classes.dex") || name.endsWith(".json")) {
                        Utils.showToast(
                            String.format("Found extracted plugin file %s in your plugins folder. DO NOT EXTRACT PLUGIN ZIPS!", name),
                            true
                        );
                    }
                    FilesKt.deleteRecursively(f);
                }
            }

            if (!PluginManager.failedToLoad.isEmpty())
                Utils.showToast("Some plugins failed to load. Check the plugins page for more info.");
        }
        loadedPlugins = true;
    }

    private static void startAllPlugins() {
        for (Map.Entry<String, Plugin> entry : PluginManager.plugins.entrySet()) {
            // coreplugins are started separately
            if (entry.getValue() instanceof CorePlugin) continue;

            var name = entry.getKey();
            try {
                if (PluginManager.isPluginEnabled(name))
                    PluginManager.startPlugin(name);
            } catch (Throwable e) {
                PluginManager.logger.error("Exception while starting plugin: " + name, e);
                PluginManager.stopPlugin(name);
            }
        }

        Utils.threadPool.execute(() -> PluginUpdater.checkUpdates(true));
    }

    private static void permissionGrantedCallback(AppCompatActivity activity, boolean granted) {
        if (granted) {
            preInitWithPermissions(activity);
            PluginManager.startCorePlugins();
            startAllPlugins();
        } else Toast.makeText(activity, "You have to grant storage permission to use Aliucord", Toast.LENGTH_LONG).show();
    }

    private static boolean checkPermissions(AppCompatActivity activity) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageLegacy()) {
            if (Environment.isExternalStorageManager()) return true;
            Toast.makeText(
                activity,
                "Please grant all files permission, so Aliucord can access its folder in Internal Storage",
                Toast.LENGTH_LONG
            ).show();
            activity.registerForActivityResult(
                new ActivityResultContracts.StartActivityForResult(),
                result -> permissionGrantedCallback(activity, Environment.isExternalStorageManager())
            ).launch(
                new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
                    .setData(Uri.parse("package:" + activity.getPackageName()))
            );
            return false;
        }

        var perm = Manifest.permission.WRITE_EXTERNAL_STORAGE;
        if (activity.checkSelfPermission(perm) == PackageManager.PERMISSION_GRANTED) return true;
        activity.registerForActivityResult(
            new ActivityResultContracts.RequestPermission(),
            granted -> permissionGrantedCallback(activity, granted)
        ).launch(perm);
        return false;
    }

    private static void safeModeDialog(Fragment fragment) {
        var desc = """
            You are currently in safe mode. Plugins are disabled.

            Press OK to exit safe mode and restart Aliucord.
            """;
        new ConfirmDialog()
            .setTitle("Safe Mode")
            .setDescription(desc)
            .setOnOkListener(widget -> {
                settings.setBool(AliucordPageKt.ALIUCORD_SAFE_MODE_KEY, false);
                Context ctx = fragment.requireContext();
                Intent intent = ctx.getPackageManager().getLaunchIntentForPackage(ctx.getPackageName());
                Utils.appActivity.startActivity(Intent.makeRestartActivityTask(intent.getComponent()));
                Runtime.getRuntime().exit(0);
            })
            .show(fragment.getParentFragmentManager(), "Disable Safe Mode");
    }
}
